feat(home): Rebuild the homepage on a declarative motion system - #115
feat(home): Rebuild the homepage on a declarative motion system#115traksaw wants to merge 28 commits into
Conversation
Port all seven sections of the redesigned homepage: hero, sky band, the three "what happens" cards, the room, open source repos, and the join CTA. Every factual claim on the page reads from getCommunityData rather than being written out, so the two copies of the member/nights/shipped line cannot disagree with each other or go stale between builds. Three deliberate departures from the design source: - The design's WebGL city is not here. three.js cost 504 KB — three quarters of all JavaScript on the site — and ~2.5s of main-thread blocking on our highest-traffic page, for decoration. Total page JS is 168 KB instead of 676 KB. GSAP stays; it drives the scroll choreography and is a fraction of the weight. - The design named a repo (p-l-otHole) and a night (Vision Lab at Indy Hall) that do not exist, and hardcoded issue counts that were already wrong. Those are djVisualizer, Workshops, and live filtered links. - The photo mosaic and its "Every photo is a real Thursday" headline render only when the gallery collection has entries, rather than putting the claim over placeholder boxes. The redesign has no Builder Night timeline, which leaves BuilderNightTrack used by no page, so it and its specs are removed along with the loop animation styles only it used. The one motion-preferences test that targeted it now covers the GSAP scroll reveals instead — those hide content by animating from opacity 0, so they are the reveal worth asserting under reduced motion. public/images/skyline.png could not be transferred from the design project and the sky band renders as a flat colour field until it lands. Restoring it is un-commenting four lines in SkylineBand.astro. Fixes #98 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbgB3FYHNAGVgvYDjXCsKB
The band shipped as a flat colour field because the design project's skyline.png could not be transferred. The source art we do have is the PhilaCon Valley poster, which is portrait: its rooflines span 2060px of a 3900px width, roughly 1.6:1, where the band is closer to 5.5:1. No crop of it both fills the band and keeps the rooflines in frame. So the tile is the poster's building cluster with the sky knocked out, joined to a mirrored copy of itself. Mirroring makes the tile's left and right edges identical, so it repeats with no seam at any viewport width. It is rolled half a cluster sideways so the mirror axis lands mid-block rather than on the matching pair of towers, where the symmetry would read. The band uses background-size: auto 100% with repeat-x rather than an <img>. That is what earns the tiling: the buildings scale to the band's height and repeat sideways to fill it, so rooflines stay in frame and bases stay flush from 390px up. A fixed-aspect <img> would have to commit to one ratio and be cropped or shrunk away from it everywhere else — at 390px wide the skyline would collapse to a 65px sliver above the fold. The wrapper takes back the role="img" and aria-label the earlier commit left a note for; there is now an illustration to describe, so it is no longer aria-hidden. Encoded near-lossless at 1400x592 — 128 KB, 51.5 dB against the lossless encode, and at least 2x the 614px it renders at in the tallest band. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.pcv-enter-word`, `.pcv-enter-stat` and `.animate-slide-up` — with their pcvWord, pcvStat and slideUp keyframes — are referenced by no .astro file. The first two were written for a hero the redesign replaced; the third predates it. All three were still being listed in the reduced-motion override block, which is how they read as live code. Also adds the implementation plan for the scroll choreography this is clearing the ground for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stat line was one interpolated string rendered in two places, which made the copies impossible to disagree but also left nothing to animate: a counter needs an element per number, and there were none. StatLine keeps the guarantee by moving it up a level — one component, one source, two call sites — and gives each number its own span carrying `data-count-to`. The span's text is already the final value, so the claim on the page is true before any script runs and stays true if none does. Pluralisation moves onto the label rather than the number, so the number stays parseable by whatever animates it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… settle The choreography is about to grow past what belongs inline in a page template. Moving it to src/scripts/ keeps index.astro readable and the motion reviewable on its own. Astro bundles the import, so it stays covered by script-src 'self' and adds no inline hash to maintain — the CSP allowlist is unchanged at one script. Adds a refresh ScrollTrigger was missing. It caches each trigger's position at creation, and two things move those positions afterwards: event-freshness.js removes the whole next-night band once the event has started, and webfonts swap in and change the height of every text block. ScrollTrigger refreshes on `load`, which covers the first; fonts can settle after that, so this asks for one more once they have. The 62px header offset becomes a named constant — pinning needs the same number, and two copies of it would drift. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The homepage shipped 44 KB of scroll runtime to ease anchor scrolling and fade nine elements up — both of which IntersectionObserver and scroll-behavior do natively. This adds the two effects that actually need a scroll runtime, and nothing else. The skyline scrubs sideways against the hero. It is bound to scroll position rather than a crossing, so it reverses when you scroll back up; an observer fires once, in one direction. The band's tile is mirror-seamless, so moving it sideways exposes no edge — the seam from the previous commit is what makes the effect possible at all. The room holds still while its numbers count up. Pinning is the other thing no observer can do: sticky can hold an element but cannot drive a timeline against the distance travelled while it holds. The pin forces a layout consequence, applied from JS rather than the markup: the room is 342px tall in a 900px viewport, so pinning it as-is would freeze the page with the next section half in frame. It grows to fill the screen only in the case that pins — desktop, motion allowed — so phones and reduced-motion visitors keep today's layout untouched. Also closes the reveal gap on the photo mosaic, which would otherwise pop in under an animated heading once the gallery has entries. Deliberately not built: the horizontal card track. Measured at 296px of travel with three cards at their real width, 656px only after widening them to make it legible. The page does not have the content to earn it. Tests assert the two properties that are the whole argument — the parallax reverses, and the section stops moving while the page keeps scrolling — from what a visitor can observe rather than from ScrollTrigger's internals, plus that both go away under reduced motion with the counters left on their true values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ways The sideways scrub was the wrong reading of the idea. The city is meant to rise to meet you on the way down the page, not slide past. The band now grows from 260px to 480px on desktop (180 to 300 on phones) as you scroll the first 560px, pushing the rest of the page ahead of it. The tile is bottom-anchored and sized `auto 100%`, so the artwork scales with the band — taller band, bigger city, still seamless across the width. It stays a scrub rather than a crossing, so it reverses on the way back up. That is still the property no IntersectionObserver can give us, and still the reason the library is here. Growing an element mid-document moves every trigger position below it, so the room's pin would otherwise fire at a stale offset. The growth ends well before the room comes into range, so one refresh at each end of the range is enough; refreshing during the scrub would cost a layout pass per frame for nothing. Fixes a false claim found while testing this. The counters were children of the pin's scrubbed timeline, which meant the tween owned the text: any instant jump past the room — restored scroll position, End key, an anchor — rendered them once at progress 0 and stopped, leaving "0 members · 0 nights held · 0 things shipped" on the page permanently. Reproduced on every instant jump, never on a gradual scroll, which is why it survived the first pass. The count-up is now a one-shot tween that always runs to its end, and if it never fires the markup's own true value is left alone. It also no longer counts down when you scroll back up, which read as members leaving. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… band Growing the band pushed the whole page down ahead of it, which made the effect read as the layout lurching rather than as the city getting closer. The band now keeps its height and the artwork scales up inside it, anchored to the bottom, so the buildings rise out of frame as though you were walking toward them. Scaling the background instead of the box takes the effect off the layout path entirely. Nothing below the band moves, no trigger position downstream goes stale, and there is no reflow per frame — which removes both ScrollTrigger.refresh() calls the previous approach needed and the layout pass it cost on every scroll event. The scale is written through a custom property rather than tweened as a background-size string: `auto 100%` has a keyword in it, and string interpolation across that is not worth relying on. Capped at 140%. Past roughly 150% the rooflines and the sky above them both crop out and the illustration stops being a skyline — it becomes a wall of building facade. 140% is a plainly visible zoom that still keeps a horizon in frame. The band tests invert with it: they now assert the artwork changes size while the band and everything below it do not move at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skyline was tried three ways — panned sideways, grown by animating the band's height, and scaled inside a fixed band — and none of them was the right idea rather than the wrong one executed badly. So it goes. The band goes with the artwork rather than being left empty. Its background is bg-brand-sky, the same #54B5FC as the hero directly above it, so with no illustration in it the band renders identically to hero padding: 260px that cannot be seen but still has to be maintained. The hero now runs straight into the next-night bar. Removes public/images/skyline.webp, the SkylineBand component, its scroll choreography, and the band's e2e coverage. The pinned room section and its counters are untouched — that is still the effect carrying GSAP, and its tests still assert it. The brand.sky token stays: it is the hero's own background now, and the comment explaining it no longer points at a component that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven pages become five. Color stops meaning which page you are on and starts meaning where you are in the story, so every page opens on the same front door and a section sliding over another means the same thing every time it happens. The visual material is deliberately unchanged — hard borders, solid offset shadows, no gradients. Depth is earned by motion instead, which is what makes GSAP structural here rather than decorative: kill the JavaScript and the site goes flat. Also records the hole this design has to close. The homepage claims every photo is a real Thursday and the gallery collection is empty, so the site sells belonging with no human being on it. Real event photography and live GitHub contributors are load-bearing, and slices 4 and 7 do not merge without them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Five tasks: capture a Lighthouse baseline, extract the choreography into a declarative motion system, prove the humanised stagger, introduce Section.astro, and assert the site degrades flat rather than blank without JavaScript. Two refinements against the spec, both recorded in the plan. Only arrive, hold and the count-up ship here — depth and seam are visible by definition and cannot be tested inside an invisible refactor, so they move to slice 4 where the homepage actually uses them. And Section records its role without rendering a colour, so adopting the component and restyling the site stay separately reviewable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
… system Pages now declare motion with data-pcv-* attributes instead of the script reaching in by CSS selector, so a contributor can add motion without opening a GSAP doc and without inventing a second way to do it. Behaviour is unchanged. The existing scroll-choreography and motion-preferences specs pass unmodified, which is the point: an invisible refactor proved by a suite written before it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Colour is about to stop meaning which page you are on and start meaning where you are in the story. Putting that rule in a component rather than a doc is what stops it decaying: five roles, no others. No colour changes here. The role is recorded, not yet rendered, so this stays reviewable and slice 3 is a single-file change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Depth is earned by motion in this design, so the failure mode of a missing bundle has to be a flat page, never an empty one. Cheap to assert now, and expensive to retrofit once depth and seams land. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Three ways the motion system could outlive its own gate, all with the same shape: something is applied inside a `matchMedia` block and then keeps applying after that block no longer holds. The count-up ran in the plain `no-preference` context with no width condition, so it fired on phones. The module it replaced ran it inside `if (room && counters.length && window.innerWidth >= 1024)`. The gate is not arbitrary: below 1024px nothing pins, so the band slides past at scroll speed and a 0.9s count is not read, it is a flicker of numbers that are briefly false. It now shares the pin's context. `hold()` set minHeight, display, flexDirection and justifyContent straight onto `element.style`. A `gsap.context` can only revert what it created, and hand-written inline styles are invisible to it, so a desktop visitor who narrowed the window past 1024px lost the pin but kept `min-height: calc(100vh - 62px)` and the flex centring forever — a phone-width band stretched to a full viewport. Routing them through `gsap.set()` makes them zero-duration tweens the context records and puts back. Chosen over returning a cleanup function from the `media.add` callback because it keeps the knowledge of *which* properties the pin owns inside `hold()`, where they are set, rather than duplicating that list in the scanner where it can drift. `countUp()` guaranteed its true value on `onComplete` only. A tween killed mid-flight — the visitor flips the OS motion preference while it runs, the context reverts — never reaches `onComplete`, stranding a half-counted "217 members" on screen permanently. That is exactly the defect the one-shot design exists to prevent, reached by another route, so `onInterrupt` now writes the same final value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
`arrive()` gave every element its own ScrollTrigger at `top 88%` and an unbounded `i * 0.09 + jitter` delay. Both parts are wrong at scale, in the same place: the twelve-photo mosaic. Photo twelve would wait ~1.05s after entering view, which stops reading as people arriving and starts reading as a broken page. It is latent on the homepage today too — the moment the first gallery photo lands, `#nights`'s heading becomes index 5 of its group and its delay jumps from 0 to ~0.49s for no reason a visitor could name. The cap is `Math.min(index, 3)` rather than the old module's `(i % 3) * 0.09`. A modulo was correct only for the three-column grid it was written for: it drops the delay all the way back to zero every third element, so element four arrives a full step before element three in any group that is not exactly three wide. `Math.min` keeps the sequence ordered up to the cap and then simply stops the wait growing — everything past the fourth item lands together, within jitter, at ~0.27s. The jitter is applied after the clamp and kept: the irregularity is the design property, the cap is only a bound. The per-element trigger is the second half. It is right for a column of blocks that cross the fold one at a time, and wrong for a grid that enters all at once — there the stagger a visitor sees is an accident of where each item's own top edge sits rather than the sequence that was designed. `arrive` now takes `shared`, which puts one trigger on the first element and fires the whole group from it. Per-element stays the default so nothing already correct changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
…rest Calling the prop `role` shadowed the real ARIA attribute. The comment in the file was careful to say it is not an ARIA role and is never forwarded — and that was true — but the consequence was that no `<Section>` could ever be given a genuine ARIA role even where one belonged, because the name was already taken. `band` says what it is: a position in the page's story. The closed `Props` interface had the same shape of problem one level up. `<Section aria-labelledby="x">` was an `astro check` error today, for a perfectly ordinary piece of markup. Props now extend `HTMLAttributes<'section'>` and everything not consumed here spreads onto the `<section>`, so ARIA and `data-*` attributes pass through without this file being edited again. The rendered attribute stays `data-pcv-section` — the motion scanner scopes its stagger groups by it and the tests read it — and the invariant that the band never reaches the DOM as an attribute is unchanged and still asserted. Also drops `data-pcv-photo` from the homepage mosaic. It was the deleted `homepage-motion.ts`'s selector and nothing in `src/` reads it now; the photos are wired by `data-pcv-arrive` like everything else. A dead attribute that looks like a hook is worse than no attribute, because the next person has to prove it is dead before removing it. The spec §5.3 and plan examples are updated to match, so the documents do not teach a prop name the code no longer accepts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Every spec ran at Playwright's Desktop Chrome default of 1280px. Both of the motion system's width gates sit at 1024px, so nothing in the suite could observe whether they existed — which is precisely how a count-up leaking onto phones survived review. A scoped second project rather than a second run of everything. Several specs encode desktop-only intent on purpose: `scroll-choreography.spec.ts` asserts the pin holds, and below 1024px it correctly does not. Widening those to pass at both sizes would delete the assertion that matters. The `mobile-chrome` project therefore runs only `mobile-*.spec.ts`, and `chromium` ignores them, so each assertion states the truth for exactly one viewport. Both projects repeat the `csp.spec.ts` exclusion. A project's own `testIgnore` replaces the top-level one instead of adding to it, so setting it on a project silently readmitted csp.spec.ts to a run that cannot serve its headers. `mobile-motion.spec.ts` is the assertion that would have caught the bug. It samples the counters every frame from first paint rather than reading them at the end, because a count-up that ran *and finished* leaves the true value in place — an end-state check cannot tell "never animated" from "animated and landed", which is the other half of why this was invisible. It also scrolls right through the band rather than using `scrollIntoViewIfNeeded`, which stops as soon as the section is visible and can leave the trigger's `top 62px` start unreached, passing for the wrong reason. Two comment corrections while here. `no-js.spec.ts` claimed to guard against a later change to a `to()` tween; with JavaScript disabled neither `from()` nor `to()` runs, so it proves only that nothing pre-hides content before scripts do — which is worth proving, and is now what it says. And the ARIA leak check listed `air` and `door`, when `room`, `work` and `invitation` are equally invalid; it now asserts no section carries an ARIA role at all, which is both the real invariant and the right one now that `Section` spreads arbitrary attributes through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
The table recorded `/about/` as a bare `84`. The three runs behind it were 0.77 / 0.84 / 0.95 — an 18-point spread on one machine, in one sitting, against one build. Spec §5.5's gate is ±3 points, so the noise is six times the thing being measured, and a reader taking `84` as precise will either chase a regression that does not exist or wave a real one through. Records the spread and states how the gate should actually be evaluated: CI as the enforcement surface, local numbers as indicative, medians of at least five back-to-back runs compared against each other rather than single runs compared against this table, and sub-5-point local moves treated as no signal at all. Disclosure only. The medians are unchanged and Lighthouse was not re-run — this is a historical record of one measurement, and rewriting the numbers would destroy the thing it exists to preserve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
The final review and its fix wave left five things that are real but not worth another round here — chiefly that arrive()'s shared-trigger option has no caller yet, and that the new stagger cap can reorder items past index 3, which is unreachable until a group exceeds four elements. Recording them in the plan rather than the scratch ledger, because the ledger is deleted with the workspace and these are inputs to the next slice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Spec and design-system.md both said yellow; the homepage shipped brand-sky. The blue was the backdrop for an illustrated skyline deleted in f1ba32a, so it is set dressing for a set already struck — and it is the only cold colour in an otherwise entirely warm printed palette, on the one band whose job is to feel like an invitation. Records the three consequences slice 3 inherits: map band="door" to yellow, retire the now-unreferenced brand-sky token rather than leave a colour nothing uses, and keep contrast.spec.ts passing unmodified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
The stagger was irregular by design and illegible in practice. A 0.09s step under a 0.7s travel resolved the whole group in 321ms — too fast to read as a sequence, so the uneven gaps landed as a stutter rather than as people arriving one at a time. Judged by comparison rather than argument: three candidate timings side by side, and the character only began reading at half speed. Both the step and the travel are doubled, not just the delays, because a halved global time scale is what was actually looked at. Cards now land at 254/398/642ms under a 1.4s travel. The cost is stated in the code and is real: the group finishes arriving two seconds after it triggers. That is the first thing to revisit if the page reads as sluggish in ordinary scrolling rather than under deliberate replay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
…the copy The hero was the last thing animating in CSS. It rose on a 0.9s keyframe with hardcoded 80ms delays while everything below it arrived on GSAP's 1.4s clock — two engines, two timings, no way to compose them, and they drifted the moment either was tuned. The first thing a visitor saw was the one part of the page outside the system meant to make the site feel like one place. Moving it exposed a worse problem than the engine. Four elements rising one at a time, evenly spaced, each alone, underneath a headline that reads "You're not the only one in the room" — the motion was making the opposite case to the copy. So the hero declares ordinals rather than taking source order: 0, 1, [beat], 3, 3. The place arrives, then you, then a pause longer than any other on the page, then the tagline and both buttons together. It is the only group on the site that lands in unison, and the exception is the argument. data-pcv-order is the whole mechanism. A shared ordinal means unison, because staggerDelay is deterministic and the same index yields the same delay and the same jitter. A skipped ordinal buys the beat. No fifth primitive, no second engine, one new attribute. Two assertions in motion-preferences changed, deliberately and not to go green: they pinned `animation-name: pcvRise`, a mechanism that no longer exists. The replacements are stronger — a real delay value under no-preference, and its absence plus a painted element under reduce, which catches the failure that actually hurts (an entrance skipped by hiding rather than by not running). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
…ges (#114) * chore: Ignore .DS_Store Browsing public/ in Finder drops these into the directory that gets deployed verbatim, where they would be served as real files. One was already sitting in public/images/ untracked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(header): Put the header on the design's dark bar and give it a scroll rule The redesign's header is a black bar with white nav and a pink rule underneath tracking how far down the document you are. The bar and the rule land here; the seven-link nav does not change, for the reason already recorded in this file — collapsing to the design's three links would orphan five reachable pages. The nav's hover and focus colour is untouched and still load-bearing: the sliding pill follows whichever link you point at, so a hovered link sits on #FF66A8 and must stay brand-dark to clear 4.5:1. Only the resting colour inverts, to white/70 on #1A1A1A. The RSVP pill's ink shadow and border go cream, because a dark offset shadow on a dark bar is invisible. Header height goes 68px to 62px. That is the height the motion system's HEADER_OFFSET has always assumed when it parks a pinned section or lands a smooth-scrolled anchor; the two disagreed by six pixels until now. The rule is deliberately not gated on prefers-reduced-motion — it reports scroll the visitor is already performing rather than animating on a clock of its own — and deliberately lives in public/ as plain JS. As a component <script> Astro inlined it, and the enforcing CSP blocked it on every page, because an inline script needs its sha256 in both policies in vercel.json. Same trade event-freshness.js already makes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(home): Anchor the next-night bar with the member count The design anchored this bar with four overlapping member avatars and a "+422" bubble. We have no member headshots and no consent to put faces on a public homepage, and three placeholder circles under a social-proof claim is the failure mode of #98 — so the bubble carries the claim alone. The number comes from config, the same hand-maintained source StatLine prints twice further down, so the three can never disagree. brand-dark on brand-purple is 5.63:1. The bar renders only when an event is scheduled, so this appears with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(content): Let the gallery collection accept JSON dates The gallery loader advertises `.json` alongside YAML, but the schema used `z.date()`, which only ever sees a Date because YAML frontmatter parses one before Zod runs. JSON has no date type and hands over a string, so every JSON entry that could be written was rejected at build time. Latent since the collection was added — it stayed empty, so nothing ever exercised it. `z.coerce.date()` accepts both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(home): Fill the room mosaic with photos from four real Thursdays The mosaic has rendered nothing since the redesign landed, because the gallery collection was empty and the headline it sits under — "Every photo is a real Thursday" — is a claim only photos can honour. Six entries from four nights: PATCH 001, the Hackathon Kickoff + Builder Night, Collab Lab, and the Kickback + Game Night. Event names come from the attendance records in community/events, not from guesswork. The homepage shows the five newest, so the Kickback entry is carried but not currently on the grid. Every candidate from the May 5 shoot is excluded despite being the strongest photography we have: that was Philly Tech Week, on a Tuesday, and the headline says Thursday. Photos are WebP at 1600px and all under 300KB, cropped from originals that are kept outside the repo — the source dump was 490MB across 117 files, which git would have carried forever. Docs pick up the collection, which was previously undocumented, plus the two non-obvious layout facts: the first entry lands in a double-height cell and wants a portrait image, and same-day entries order by filename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(home): Serve the mosaic at the size it is actually displayed The tiles render about 194px wide on a phone and 425px on a desktop, but every file was 1200px. A phone downloaded 674KB to draw five thumbnails, roughly thirty times the pixels it can show, below the fold, on cellular. Each image is normalised to a 1200px canonical with `-400` and `-800` siblings, and the mosaic emits a srcset over that fixed ladder. Measured on the built site: a 390px phone at 3x goes 674KB to 308KB, desktop at 1x the same, and desktop at 2x correctly keeps choosing the 1200px file because at that density it genuinely needs it. The ladder is fixed rather than derived so every width in the srcset is a constant the page can state truthfully. e2e/gallery-images.spec.ts fetches every candidate URL and asserts three rungs per photo, so a contributor who copies one file instead of three fails CI instead of shipping a 404 that only phones would see. Not astro:assets, which would generate all of this: its image service needs sharp, and adding a native binary dependency to a repo aimed at first-time contributors is a call for the maintainer, not this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: Remove the tracked .DS_Store from public/ It was committed before .DS_Store was ignored, and public/ is copied verbatim into the deploy — so it was being served as a real file. The ignore rule added earlier does not untrack a file that is already in the index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
traksaw
left a comment
There was a problem hiding this comment.
Overview
Reviewed by building the branch in a throwaway worktree and measuring the things worth measuring rather than reasoning about them. Everything marked "measured" below comes from Chromium at 1440×900 against the real build.
Three changes braided together: a homepage rebuilt on the design's tokens, a declarative motion system (data-pcv-arrive / data-pcv-hold scanned by one file, all preference-gating in a single matchMedia), and six real event photos behind a responsive srcset ladder.
The architecture holds up. The primitives/scanner split is the right seam, matchMedia over a JS-branch-plus-parallel-CSS-block is the right call for the reason stated, and the test suite is unusually serious — the mobile-chrome project existing specifically to assert a gate the desktop project cannot see is the kind of thing most repos never do.
The comments are load-bearing and mostly excellent. Two of them are now wrong, which matters more than usual precisely because they are written to be trusted.
Blocking
1. The Join band reintroduces the white-on-pink defect this repo is actively fixing
brand.pink is #FF66A8 — the same token as accent-400. index.astro:369 ships bg-brand-pink text-white. Measured, alpha-composited:
| Element | Ratio | Needs |
|---|---|---|
#join h2 (54px/800) |
2.72:1 | 3.0 |
#join p (19px, white/90) |
2.46:1 | 4.5 |
#join StatLine (13px, white/80) |
2.23:1 | 4.5 |
| Hero pill "Come to a night" (21px/700, white on pink) | 2.72:1 | 3.0 |
.pcv-tag "Builder Night" (11px/700, white on pink) |
2.72:1 | 4.5 |
.pcv-tag "Collab Lab" (11px, white on brand-purple) |
~3.0:1 | 4.5 |
This is #96 verbatim, on a new page. #116 is open right now flipping exactly this pairing to text-brand-dark (6.40:1), and it will conflict with this file. The heading fails even the 3:1 large-text threshold.
Fix is the same one #116 lands: text-brand-dark on every pink surface, dark tag text, and drop the white/80–90 opacity steps — they make it worse, not softer.
2. Two more surfaces land under AA
text-brand-dark/[0.55]card meta, 12px on white (index.astro:247) — 3.89:1#nightscard StatLine,text-brand-dark/6012px on cream (index.astro:305) — 4.37:1- Hero eyebrow
text-brand-dark/[0.72], 12px onbrand-sky(index.astro:137) — 4.46:1, just under
The 19px/16px body copy at /72 is fine (6.45 and 6.88). It is the 12px tier that fails. /70 on white at 12px gets to ~5.7:1.
Related, and worth knowing before fixing the above: contrast.spec.ts's helper parses color with nums.slice(0, 3) and discards the alpha channel, so translucent text is measured as if opaque. It reports that hero eyebrow as 7.78:1 rather than 4.46:1. That blind spot cost nothing before; this page is built on /72, /60, /55 text, so it does now.
3. The stagger inverts past the cap — measured on the mosaic
data-pcv-delay on the five #nights tiles, read off the live page:
[0, 0.254, 0.398, 0.642, 0.556]
Tile 5 arrives 86ms before tile 4. Past STAGGER_CAP the cumulative term is constant, so JITTER alone orders the tail — and JITTER[3]=0.102 > JITTER[4]=0.016.
That is the same defect the comment at primitives.ts:42-48 says it exists to fix ("element four arrives a full step before element three — visibly wrong"), reintroduced at smaller amplitude. The comment's claim that Math.min "keeps the sequence ordered up to the cap and then simply stops the wait growing" is true for the first half and false for the second.
motion-system.spec.ts asserts strict monotonicity — but only on #what .pcv-card, a 3-element group that never reaches the cap. The five-tile mosaic is the only group on the page that does, and nothing checks it.
Either make the tail monotone (a decaying step, e.g. base * (CAP + Math.log1p(i - CAP)), keeps growth bounded and ordered), or keep the flat tail and drop the jitter past the cap so they genuinely land together. Then point the monotonicity assertion at #nights, not #what.
4. The skip link stops skipping
smoothAnchors binds every a[href^="#"] (primitives.ts:250). That includes BaseLayout's "Skip to main content".
Measured on the homepage: Tab → Enter leaves document.activeElement on the skip link itself and location.hash empty. preventDefault() suppresses the browser's fragment navigation, which is what moves the sequential-focus starting point into <main> — so a keyboard user activates the skip link and the next Tab goes straight back into the nav.
Two fixes, both needed:
- Exclude the skip link (
:not([data-pcv-no-smooth]), or skip#main-content), and - after the scroll settles,
history.pushState(null, '', sel)and focus the target (el.setAttribute('tabindex','-1'); el.focus({ preventScroll: true })).
The missing hash also means "See what happens" → #nights no longer updates the URL, so section links cannot be shared or backed out of.
Non-blocking
arrive()'s options are unreachable. The scanner only ever callsarrive(group)—opts.yandopts.sharedhave no caller and no markup hook. The mosaic is precisely the grid theshareddoc comment describes ("a photo mosaic… one trigger on the first element fires them all"), and it does not get one. Either wire it (data-pcv-arrive="shared") or delete the parameter; a documented option nothing can reach is worse than neither.- No test asserts the pinned section's children end visible. I probed it — all six
#nights [data-pcv-arrive]end at opacity 1, so this is not a live bug. But those triggers live inside a pinned container without GSAP'spinnedContainerset, so their cached start positions do not account for the pin. Today the geometry happens to work.no-js.specchecks opacity only with JS off;motion-preferenceschecks#what;gallery-imagesreadscurrentSrc, which is happy at opacity 0. A stuck-invisible mosaic would ship green. One assertion on#nights [data-pcv-arrive]opacity, JS on, desktop, closes it. - The sixth photo never renders. Six gallery entries,
.slice(0, 5).kickback-game-night-group(2025-08-28) is oldest and always dropped — its JSON and three webp files ship unused. The description says six entries were added. Bump the slice or say the sixth is a bench. srcset()assumes.webp.image.replace(/\.webp$/, '')turns a.jpgentry intofoo.jpg-400.webp. The e2e catches it as a 404, butz.string().regex(/\.webp$/)on the schema catches it at build with a better message.- The ladder's width claim is not verified.
${image} 1200wis asserted by convention;gallery-images.specproves the URLs resolve, not that the canonical is 1200px. A 900px file labelled1200wmakes the browser pick the wrong rung and nothing goes red. AnaturalWidthcheck is a two-line addition to that spec. - Stale comment.
Header.astro:33still says "driven bysrc/scripts/scroll-progress.ts". It ispublic/js/scroll-progress.js— and the description devotes a paragraph to why. Worth fixing precisely because these comments are otherwise reliable. - FOUC on the hero.
gsap.fromwithimmediateRendermeans the hero paints from HTML, then the deferred module yanks it to opacity 0 and replays it. Above the fold, on a cold cache, that is a visible flash-and-rewind. No test can see it; worth one look under throttling before merge. staggerDelaywith a negative ordinal returnsNaN(JITTER[-1]is undefined).data-pcv-order="-1"is unlikely, butMath.max(0, …)is free.
Risks
- Bundle: GSAP core + ScrollTrigger + ScrollToPlugin is ~70KB min / ~30KB gzip, homepage only. Justified by the pin (correctly argued in
scroll-choreography.spec), butdocs/perf-baseline.mdpredates both the motion system and the mosaic and says so itself. Re-baseline before that ±3 gate is enforced. - Merge conflict: this file collides with #116 on the pink CTAs. Whichever lands second has to reconcile — worth deciding the order now.
- Security: no new surface. Keeping GSAP out of a CDN and
scroll-progress.jsinpublic/both preservescript-src 'self'; the reasoning is right and worth keeping in the comments.
Verdict: the architecture holds up and the test suite is better than most of what it is testing. Fix the contrast (1–2), the stagger tail (3), and the skip link (4) — the first and last are accessibility regressions on the site's front door, and all three are small edits against a design that is otherwise sound.
Rebuilds the homepage from the Claude Design source (
Homepage.dc.html), movesits choreography onto a declarative motion system that pages drive from markup
rather than inline GSAP, and fills "The room" with photos from four real Builder
Nights.
The homepage
src/pages/index.astrois the redesign: the hero, the next-night bar, the threeprogramme tracks, "The room", the open-source cards, and the pigeon CTA, on the
design's own tokens. Where the design asserted something this repo could not
back, the page says less rather than more, and each of those decisions is a
comment next to the code that makes it:
and link to the live filtered list instead — a number frozen at build time is
the same bug as Homepage community numbers freeze at deploy time — move to ISR instead of the client-side date patch #94.
p-l-otHoleis replaced by repos that exist.this repo or the org, so the third track is Workshops, which do.
and no consent to put faces on a public homepage, and placeholder circles under
a social-proof claim is the failure mode of Builder Night timeline states four invented times, one of them after the event ends #98.
The motion system
src/scripts/motion/is the whole vocabulary. Pages declaredata-pcv-arriveand
data-pcv-hold; the scanner inindex.tsis the only file that decides whatanimates. A contributor who has never opened a GSAP doc gets motion matching the
rest of the site, because there is exactly one implementation of each move.
Everything gated on motion preference lives in a single
gsap.matchMedia()block. A JS branch plus a parallel CSS block is two sources of truth that drift,
and matchMedia also reverts its tweens on cleanup, which for a
from()restoresthe element's real visible state.
Section.astrogives each band a story position — open air, the door, the room,the invitation — as a prop that cannot be quietly ignored, unlike a convention.
The room, and its photos
The mosaic renders only when the gallery collection has photos, because "Every
photo is a real Thursday" is a claim only photos can honour — shipping it over
placeholder boxes is #98 at larger scale. Six entries now come from PATCH 001,
the Hackathon Kickoff + Builder Night, Collab Lab, and the Kickback + Game Night,
with event names taken from the attendance records in
community/eventsratherthan guessed.
Every candidate from the May 5 shoot is excluded despite being by far the
strongest photography we have: that was Philly Tech Week, on a Tuesday, and the
headline says Thursday. Changing that copy would unlock 31 better photos — worth
deciding separately.
Images carry a fixed
-400/-800srcset ladder beside a 1200px canonical.Measured on the built site, a 390px phone at 3x goes 674KB to 308KB.
e2e/gallery-images.spec.tsfetches every candidate URL, so a missing variantfails CI instead of shipping a 404 only phones would hit. Deliberately not
astro:assets, which would generate this automatically — its image service needssharp, and adding a native binary dependency to a repo aimed at first-timecontributors is a maintainer's call.
The skyline is gone, deliberately
It was built and then tried three ways: panned sideways, grown by animating the
band's height, and scaled inside a fixed band. None of them was the right idea
rather than the wrong one executed badly, so
f1ba32aremoves it. Thebrand.skytoken stays because it is the hero's own background now.That history is left in the commits rather than squashed — the next person to
propose a skyline should be able to see what was already tried.
Two latent bugs fixed along the way
.json, but its schema usedz.date(), which only ever sees a Date because YAML frontmatter parses onebefore Zod runs. Every JSON entry that could be written was rejected at build
time — unnoticed because the collection stayed empty.
public/.DS_Storewas already tracked, so it was being served as a real file.Adding the ignore rule does not untrack it.
Worth a reviewer's attention
min-width: 1024px.Below that nothing pins, so a 0.9s count is not read, it is a flicker of wrong
numbers going past.
e2e/mobile-motion.spec.tsruns at a phone viewportspecifically to assert that gate holds.
e2e/no-js.spec.tsasserts the site degrades flat rather than blank, which isthe risk of moving entrances into JS at all.
public/js/scroll-progress.jsis plain JS inpublic/rather than a component<script>on purpose. As the latter, Astro inlined it and the enforcing CSPblocked it on every page — an inline script needs its sha256 maintained in both
policies in
vercel.json. Same tradeevent-freshness.jsalready makes.HEADER_OFFSEThas alwaysassumed when parking a pinned section. The two disagreed by six pixels.
docs/perf-baseline.mdrecords Lighthouse numbers taken before the motionsystem, and argues against its own precision: the local run-to-run spread is
six times the ±3 gate the spec sets. It predates the mosaic shipping real
images, so re-baseline in CI before that gate blocks anything.
BuilderNightTrack.astroand its spec are deleted; that job is now the tracksgrid on the homepage.